Repository navigation
feat(session): extend native manual compaction controls - #614
Merged
Merged
Conversation
added 4 commits
November 2, 2026 23:57
This was referenced Nov 3, 2026
Merged
stamp-build Bot
added a commit
that referenced
this pull request
Nov 5, 2026
<!-- stamp:review:v1 --> <sup><img src="https://api.raft.build/api/avatars/pixel/bXVn.svg" width="16" height="16" align="absmiddle" alt=""> <b>Cindy</b> from open-agent-runtime opened this pull request through Stamp</sup><br><sup><a href="https://stamp.build/review?receipt=prc_86f84741dcdea5594799372ff3c6cdfa"><picture><source media="(prefers-color-scheme: dark)" srcset="https://stamp.build/assets/brand/stamp-glyph-dark.d907ffcadb44.svg"><img src="https://stamp.build/assets/brand/stamp-glyph-light.58a2c10ed108.svg" width="13" height="12" align="absmiddle" alt=""></picture> View in Stamp →</a></sup> --- <!-- /stamp:review:v1 --> <!-- stamp:review:v1 --> <sup><img src="https://api.raft.build/api/avatars/pixel/bXVn.svg" width="16" height="16" align="absmiddle" alt=""> <b>Cindy</b> from open-agent-runtime opened this pull request through Stamp</sup><br><sup><a href="https://stamp.build/review?receipt=prc_86f84741dcdea5594799372ff3c6cdfa"><picture><source media="(prefers-color-scheme: dark)" srcset="https://stamp.build/assets/brand/stamp-glyph-dark.d907ffcadb44.svg"><img src="https://stamp.build/assets/brand/stamp-glyph-light.58a2c10ed108.svg" width="13" height="12" align="absmiddle" alt=""></picture> View in Stamp →</a></sup> --- <!-- /stamp:review:v1 --> Minor release: manual compaction (`Session.compact()`, added in 0.86.0) now also works on Grok, Qoder, Cursor and OpenCode v2 sessions. Compaction events gain the runtime's own operation identity, and a native frame can now say which OAR request it answers. ACP queues now record when OAR actually sends a held input, which **changes the request records hosts see on Kimi, Grok, OpenCode, Antigravity and CodeBuddy**. A host reacting synchronously to a turn ending can now send its next prompt right away on every runtime except Pi, unless OAR is still holding queued input. ## Known issue: Qoder 1.1.82 and newer - On Qoder 1.1.82 and newer, opening a session with a `model` whose native reported name differs from the selector (for example `efficient`, reported as `Efficient`) is refused, because Qoder's catalog no longer has the field OAR uses to confirm the selection. Earlier OAR releases are affected too. The fix is planned for 0.87.1. - OAR's CI now runs Qoder at 1.1.81, because the native replay it tests with stopped working in 1.1.82. Qoder behaviour in this release, including manual compaction, is verified on 1.1.81 and has no execution verification on 1.1.82 or newer. ## Manual compaction on more runtimes (#614) - Newly supported, idle sessions only (`activeTurn: "reject"`): **Grok**, **Qoder** and **Cursor** declare `Runtime.manualCompaction` as supported. - **OpenCode**: sessions on OpenCode v2 have `compact`; OpenCode v1 sessions do not, because a project command can replace `/compact` and completion cannot be confirmed. OAR reads the version when the session opens (from the installation, or by running `opencode --version`), so the shared OpenCode `Runtime` stays undeclared: check `session.compact` after opening. Shared placement is v2-only and has it. - **Pi Durable** stays undeclared; it moves with the separate Pi Durable 1.5 API migration. - **Cursor** declares support, but the session has `compact` only when the SDK agent provides `summarize()`. - How each new runtime decides the outcome: - **Grok:** an object answer to its dedicated `_x.ai/compact_conversation` request is `completed`. Structured failure or cancellation errors give `failed` or `aborted`; other RPC errors give `rejected` (`runtime_refused`); a null answer gives `unconfirmed` and frees the session. OAR reports no `compaction_started` for Grok. - **Qoder:** a `started` lifecycle, a manual `compact_boundary` and completion, all for the same command UUID. Success without a boundary gives `failed` (`not_compacted`); an error result gives `failed` (`native_failed`); cancellation before a boundary gives `aborted`. A boundary already seen stays `completed` even if the command later reports cancelled. - **Cursor:** `Agent.summarize()` returns a run id; that run's root summary and terminal result decide. A finished run without a terminal summary gives `unconfirmed` but frees the session; a cancelled run without one gives `aborted`; an SDK refusal gives `rejected` (`runtime_refused`). A lost stream or rejected `run.wait()` gives `unconfirmed` and keeps the session reserved until the run ends or `dispose()`. Cursor SDK 1.2.0 `summarize()` cannot pass `local.force`, so it refuses a restored agent whose run is still marked active. - **OpenCode v2:** native compaction start and end markers with the same `messageId`, plus the answer to the `/compact` prompt. An ordinary successful prompt is not proof. A native RPC error gives `failed` (`native_failed`); an answer without matching markers gives `unconfirmed` and frees the session. - On all four, losing the transport before a known answer gives `unconfirmed` and keeps the session reserved until `dispose()`. - Compacting an empty history differs by runtime: Grok `completed` (a native no-op), Qoder `failed` / `not_compacted`, OpenCode v2 `failed` / `native_failed`, Cursor `rejected`. - While compaction is unresolved, `queue()` input is held and sent after it ends on Claude, Pi, Grok, Qoder, Cursor and OpenCode v2; Codex still refuses it as `busy`. `abort()` uses the runtime's own cancel: `session/cancel` on Grok and OpenCode v2, `run.cancel()` on Cursor (an abort before the run exists cancels it once `summarize()` returns), and the interrupt on Qoder. ## Compaction events and new record fields (all optional and additive) - **Runtimes that now report compaction events:** Qoder emits `compaction_started` on its `compacting` status, automatic compactions included. OpenCode v2 emits `compaction_started` and `compaction_ended` for every compaction, with the native `trigger`. Grok emits `compaction_ended` for manual compactions. - `compaction_started.compactionId?` and `compaction_ended.compactionId?`: the runtime's own identity for that compaction (currently only OpenCode v2's `messageId`), present on automatic compactions too. It is not the envelope's turn `spanId`. - `compaction_ended.requestId?`: set only when the native response explicitly answers that OAR `compact` request (currently Grok). Never inferred from timing. - `FrameBody.responseTo?`: the id of the OAR request this native frame explicitly answers. Currently set on Grok and OpenCode v2 compact RPC answers, and on Qoder's terminal `command_lifecycle` whose `command_uuid` is the id OAR sent. Exact equality only; it does not mean the request succeeded. - `compact` requests can carry `settledBy: "native_response"` (Grok, OpenCode v2, Qoder): only a frame whose `responseTo` equals that request id ends the operation. Earlier `turn_ended` or `compaction_ended` events stay in the stream but do not end it. - New native frame types: Grok `_x.ai/compact_conversation` and `_x.ai/compact_conversation/error`. OpenCode v2 compact answers reuse `session/prompt` and `session/prompt/error`, but carry `responseTo` and no `turn_ended`. - **Cursor:** - Frames now carry the native run id as the envelope `spanId`: callback frames (child tasks too), stream frames with a `run_id`, `cursor/run_result`, and run failure frames. Root session and child-task attribution are unchanged. - The compact `accepted` response carries `native: { runId }`. - `cursor/stream/summary` frames whose native `agent_id` is missing or is not the root session no longer carry compaction events, automatic summaries included. - A `cursor/run_failed` frame during an unresolved compaction carries no `turn_ended`. - Callbacks that arrive before `send()` returns are recorded after it, keeping their original `receivedAt`, so `receivedAt` is not always increasing. ## Behaviour change: ACP queue records (Kimi, Grok, OpenCode, Antigravity, CodeBuddy) - **Every accepted ACP `queue()`, even on an idle session, now answers `accepted` with `queued: "adapter"`.** When OAR sends it to the runtime, the stream gets a **new `prompt` request** whose `carries` names that input; the queue request gets no second response. Before, an idle queue was sent at once with no prompt request. - **Hosts that count requests or group by request kind** must treat that prompt as part of the same submission, not a new host input. The dispatch prompt carries only `input`, `carries` and `images`; read `inputId` and `origin` from the carried queue input. A queue with no `inputId` is identified by its queue request id within the same record stream, and that fallback never links across a resumed stream. - Conversation and session views show queue plus dispatch as one input. - ACP `prompt()` now returns `busy` while OAR holds or is sending a queued input, not only during an active turn. A host `prompt({ carries: [heldId] })` now returns `busy` instead of `unsupported`, and that attempt is added to the held input. - While OAR holds an ACP input and no turn is running, status shows `running` / `waiting_model` instead of `idle`, until the dispatch prompt is accepted or a `withdraw` is accepted. If the dispatch is rejected, the input stays held. ## Bug fix: the next prompt can follow a turn end immediately - A synchronous observer of `turn_ended` could see `idle` but still get `busy`, and in Codex compaction the old cleanup could clear the turn the observer had just started. OAR now releases its own turn ownership before publishing the terminal frame: - ordinary turns on Claude, Qoder, Qwen, Codex, Cursor and the five ACP runtimes; - compaction on the runtimes that support it. - Native payloads and the relative order of frames are unchanged. - Held input keeps its place: a new `prompt()` or `queue()` from that observer cannot overtake input OAR is already holding. - A `compact()` the runtime refuses at dispatch releases its reservation before observers see that refusal restore `idle` (every runtime with `compact()`). - **Known gaps:** - **Pi SDK and Pi RPC ordinary turns are not fixed yet:** a synchronous `turn_ended` observer there still gets `busy`; send the next prompt after the callback returns. - **Claude, Qoder, Qwen and Cursor:** while OAR holds a queued input, `status()` can show `idle` while `prompt()` returns `busy` to keep first-in, first-out order. These four do not yet record queue ownership the way ACP now does. - With no held input, status and prompt admission change on the same record on every runtime except Pi. Both gaps are tracked as follow-ups. ## Upgrade notes - **Rejected outcome, accepted response:** a `rejected` outcome can now come with an `accepted` response. When the runtime refuses after accepting the dispatch (currently only Grok), `outcome.kind` is `rejected` while `outcome.response.body.kind` stays `accepted`; the refusal is in the correlated frame. Don't assume a rejected outcome has a rejected response. - **Status checkpoints:** hosts that fold status incrementally must still persist the complete `StatusState`, as in 0.86.0. It now also holds the compaction identity learned from the first native manual start, the native settlement rule, and ACP held-queue ownership. `SessionView.statusState` can now appear outside compaction too. - **Custom runtimes:** the status fold honours `accepted.queued: "adapter"`, `settledBy` and `responseTo` from any runtime built on the kernel SPI. - **Public types:** no union members were added (event, request and response kinds, `RejectionCode`, `ControlAction`, `CompactionOutcome` kinds and codes), and no exports changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Cindy <open-agent-runtime+cindy@agents.stamp.build>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hosts can call the existing optional
Session.compact()on Grok, Qoder, Cursor and OpenCode v2 sessions. Completion requires native compaction evidence; a successful ordinary prompt is insufficient. Busy sessions refuse the control, caller cancellation only stops waiting, and queued input waits until native termination. OpenCode v1 has nocompactmember; Pi Durable remains deferred to its native API migration.The result and input gate now distinguish a known native end from lost observation. OpenCode errors are retained as frames; a reply without matching compaction evidence and Grok's null reply return
unconfirmedand release the gate. Only Grok's structuredcompact_failed/compact_cancellederrors report failed / aborted compaction; other RPC errors are typed refusals with no invented end. Grok and OpenCode admit the control after SDK dispatch.Native terminal records release the manual-operation reservation and folded status together. Admission also respects held input, with the queue exception below. Grok/OpenCode RPC answers and Qoder's exact command terminal carry
FrameBody.responseTo; their compact requests carrysettledBy: "native_response". Earlier compaction/turn events remain in place. NativecompactionIdand directly correlatedcompaction_ended.requestIdpreserve their distinct meanings. Checkpoint consumers must retain the complete exportedStatusState, including native correlation and adapter queue ownership.ACP queue acceptance adds
queued: "adapter"; draining records a new prompt withcarriesreferencing the original input identity, and that prompt's single response. This also applies to ordinary queues on Kimi, Grok, OpenCode, Antigravity and CodeBuddy. Hosts counting requests or grouping by kind must usecarriesto avoid treating the extra prompt as another user input. When inputId is absent, the queue request identity links dispatch within the same stream, so the view still renders one input. Foreign session, agent and resumed-stream carries are refused. No native queue event is fabricated, and the original queue request receives one response. Status remains running between native completion and dispatch/withdrawal, so a new prompt cannot overtake held inputs. Existing queue and interrupt tests now assert these explicit records and retained ownership.Cursor retains the existing root envelope
sessionIdand task/agentPath attribution. Nativeagent_idstays in the payload; native run ids becomespanIdon both stream and callback frames, including callbacks emitted beforesend()returns, retaining their ingress timestamp. An immediate rejection handler prevents host-level unhandled rejection when abort overlaps summary startup. SDK 1.2.0summarize()has nolocal.forceoption: an offline real-store probe confirms a restored active run is refused without starting an executor or making a fetch call. No account probe was repeated.A separate ordinary-turn fix covers Claude, Qoder, Qwen, Codex, Cursor and the shared ACP driver (Kimi, Grok, OpenCode, Antigravity and CodeBuddy): internal ownership releases before publishing the terminal frame. With no held input, a synchronous turn_ended observer can send the next prompt, and old cleanup cannot clear it. Held input retains FIFO priority over new prompts and queued input. Parameterized Claude/Qoder/Qwen regressions call both controls from synchronous terminal observers; the queue cases finish all three turns and verify A, B, C dispatch order. Removing the queue hold guard fails all three runtime cases. Cursor controls preserve its existing order. Native payloads and relative frame order remain unchanged, as do existing replay snapshots.
A documented exception remains for OAR-held queues on Claude, Qoder, Qwen and Cursor: status can show idle while prompt returns busy. Their explicit queue ownership/dispatch records and refusal handling are tracked separately in Raft task 161, after this PR. This does not weaken same-record admission without held input.
Report, selected native evidence, reproduction scripts and release-note draft describe exact versions and limits. The retained Cursor account experiment is manual only; its available usage is documented and it is excluded from CI.
Validation of the latest queue guard: the focused three-file run passes all 34 tests; the new observer-queue cases fail on the preceding head for Claude, Qoder and Qwen.
CI=1 pnpm run checkpassed (3,141 tests, 18 mock behavior checks), and all 14 behavior backends passed. The preceding revision passed build, clean packed-host installation and full vendor suites for Claude (80), Qoder (12) and Qwen (18). Earlier review validation also passed Codex (62), Grok (20), OpenCode v1 (10), 16 Pi SDK/RPC 1.5.0 compaction controls and six OpenCode v2 2.2.5 controls; those native compaction paths are unchanged in this revision. Fourteen review mutations were caught; two additional before/after regressions cover startup-refusal reentry and buffered callback timestamps. The latest regressions also fail before the no-ID carry fix, stream-json FIFO guard and ACP terminal release ordering. Cursor FIFO and no-queue reentry controls pass. Existing replay snapshots are unchanged.Local native evidence is Linux x64. Existing CI retains its OpenCode v1 pin; the six new v2 controls are tested separately with 2.2.5. Five unrelated MCP cases in the broader v1 vendor suite also fail on unchanged base when run against v2, so this PR does not claim whole-suite v2 support. No workflows or dependency pins change.